Skip to content

feat: runtime-configurable sub-path serving#1600

Open
mm-mbs wants to merge 3 commits into
sysadminsmedia:mainfrom
mm-mbs:feat/runtime-subpath
Open

feat: runtime-configurable sub-path serving#1600
mm-mbs wants to merge 3 commits into
sysadminsmedia:mainfrom
mm-mbs:feat/runtime-subpath

Conversation

@mm-mbs

@mm-mbs mm-mbs commented Jul 6, 2026

Copy link
Copy Markdown

What type of PR is this?

  • feature

What this PR does / why we need it:

Adds sub-path support. You can now serve HomeBox under a path like /homebox/ by setting one env var at runtime. No rebuild needed.

podman run -e HBOX_WEB_APP_BASE=/homebox homebox
podman run -e HBOX_WEB_APP_BASE=/apps/inventory homebox                                                                                                     
podman run homebox  # default: serves at /     

Without setting the var, everything works as before (served at /).

This is similar to what #1430 does, but #1430 requires rebuilding the image for each path. Here it's runtime only.

How it works

The frontend uses cdnURL: "./" so assets get relative paths. On startup the backend reads HBOX_WEB_APP_BASE and patches the index.html in memory — injects <base href> so the browser resolves assets correctly, sets window.__HOMEBOX_BASE__ for the JS side, and patches baseURL in the Nuxt config so Vue Router works with the sub-path.

Routes (API, Swagger, static files) are all mounted under the base. Paths outside the base get 404.

What changed

Backend:

  • conf.go — new AppBase field, validated with regex, normalized to /path/ form
  • routes.go — mounting under base, index.html patching at startup, root redirect, 404 for wrong paths
  • oidc.go, v1_ctrl_auth.go — cookie paths and redirects use AppBase
  • controller.go — passes web config to OIDC provider

Frontend:

  • nuxt.config.tscdnURL: "./", PWA denylist fix
  • New useAppBase() composable + plugin that configures the URL builder
  • WebSocket URL, login redirect, invite links all use useAppBase()
  • app.vue — links to favicon/icons/manifest made relative
  • otel endpoint uses route() now

Tests:

  • conf_normalize_test.go — normalization + invalid input
  • index.test.ts — URL builder with custom base

Infra:

  • Dockerfile healthcheck adapted for sub-path
  • docker-compose.yml documents the new var

Which issue(s) this PR fixes:

Special notes for your reviewer:

The baseURL:"/" string replace is not great — it depends on exact Nuxt output format. But Vue Router needs this value before hydration and there is no other way to set it at runtime. There is a warning log if the pattern is not found.

Input is validated strictly (only [a-zA-Z0-9/\-_.] allowed) and html-escaped before injection.

Cookies use AppBase as path so multiple instances on same domain don't conflict.

Reverse proxy should NOT strip the prefix. Backend expects the full path.

Testing

  • go vet passes, unit tests pass
  • Tested with podman: /, /homebox, /app/inventory all from same image
  • Checked login, navigation, websocket, swagger, assets, redirects, 404
  • Without the env var: same behavior as before

Allow serving HomeBox under a sub-path (e.g. /homebox/) by setting
HBOX_WEB_APP_BASE at runtime. Same image works for any path, no
rebuild needed.

Backend patches index.html at startup with <base href> and fixes
Nuxt's baseURL config. API/Swagger/static files mount under the
configured base. Requests outside the base return 404.

Frontend uses cdnURL "./" for relative assets and reads the base
from window.__HOMEBOX_BASE__ via useAppBase() composable.

Supersedes sysadminsmedia#1430 (which requires a rebuild per path).
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a normalized AppBase setting and propagates it through backend routing, OIDC, cookies, Docker health checks, and frontend URL generation so the app can run under a sub-path.

Changes

App base path support

Layer / File(s) Summary
AppBase config and normalization
backend/internal/sys/config/conf.go, backend/internal/sys/config/conf_normalize_test.go
Adds AppBase to WebConfig, normalizes it during config load, and tests valid and invalid path forms.
OIDC provider and cookie path updates
backend/app/api/handlers/v1/controller.go, backend/app/api/providers/oidc.go
Passes WebConfig into NewOIDCProvider, uses AppBase in OIDC redirect URL construction, and scopes OIDC flow cookies to AppBase.
Auth cookie scoping and redirect targets
backend/app/api/handlers/v1/v1_ctrl_auth.go
Scopes auth cookies to AppBase and updates OIDC callback redirects to use base-aware success and failure targets.
Route mounting and base-aware SPA fallback
backend/app/api/routes.go
Mounts Swagger and v1 routes under AppBase and rewrites SPA fallback handling to inject a base tag, redirect subpath roots, reject out-of-base requests, and serve patched index.html on fallback.
Docker healthcheck and compose docs
Dockerfile, docker-compose.yml
Updates the container healthcheck to build its probe URL from HBOX_WEB_APP_BASE and documents the sub-path environment setting.
Frontend useAppBase composable and URL builder
frontend/composables/use-app-base.ts, frontend/lib/api/base/urls.ts, frontend/plugins/api-base.ts, frontend/lib/api/base/index.ts, frontend/lib/api/base/index.test.ts
Adds useAppBase(), extends API URL parts with a base segment, wires early plugin setup, re-exports the helper, and adds route-construction tests.
Frontend consumers of app base
frontend/pages/index.vue, frontend/composables/use-server-events.ts, frontend/lib/otel/index.ts, frontend/pages/collection/index/invites.vue, frontend/components/Collection/JoinModal.vue, frontend/app.vue, frontend/nuxt.config.ts
Updates OIDC login, WebSocket URLs, telemetry export, invite links, join modal links, asset paths, and Nuxt PWA/proxy settings to use the runtime app base.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant BackendRoutes as backend/app/api/routes.go
  participant OIDCProvider as backend/app/api/providers/oidc.go
  participant NuxtApp as frontend app

  Browser->>BackendRoutes: request under AppBase
  BackendRoutes-->>Browser: patched index.html / mounted base-aware routes
  NuxtApp->>NuxtApp: read window.__HOMEBOX_BASE__
  NuxtApp->>BackendRoutes: login, websocket, and telemetry requests under AppBase
  BackendRoutes->>OIDCProvider: OIDC flow with base-scoped cookies and redirects
  OIDCProvider-->>Browser: callback redirect to AppBase + home
Loading

Possibly related PRs

  • sysadminsmedia/homebox#1256: Shares the backend route wiring area in backend/app/api/routes.go, where route registration and request handling were modified.

Suggested labels: ⬆️ enhancement, go

Suggested reviewers: tankerkiller125, katosdev, tonyaellie

Security recommendations

  • Verify normalizePath rejects encoded traversal, control characters, and other unexpected path forms beyond the tested characters.
  • Confirm all AppBase injection into HTML remains escaped before rendering <base href> and window.__HOMEBOX_BASE__.

Poem

A base path bloomed beneath the light,
Cookies, routes, and assets took flight.
Under /homebox, things now steer,
With redirects, healthchecks, and URLs clear.
A sub-path home feels snug and right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: runtime-configurable sub-path serving.
Description check ✅ Passed The description includes the required sections, explains the change, lists issues, notes, and testing, and is mostly complete.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the ⬆️ enhancement New feature or request label Jul 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (7)
frontend/composables/use-app-base.ts (1)

12-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider typing the global instead of casting to any.

For maintainability, declare a proper Window interface augmentation (e.g. interface Window { __HOMEBOX_BASE__?: string }) rather than casting to any. Also consider guarding against a malformed value here (e.g. one that doesn't start with /), since it flows unchecked into URL construction downstream (see urls.ts) — a defense-in-depth measure worth having for security hygiene, even though the backend is expected to normalize this value.

♻️ Optional typing improvement
+declare global {
+  interface Window {
+    __HOMEBOX_BASE__?: string;
+  }
+}
+
 export function useAppBase(): string {
   if (typeof window === "undefined") return "/";
-  return (window as any).__HOMEBOX_BASE__ || "/";
+  return window.__HOMEBOX_BASE__ || "/";
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/use-app-base.ts` around lines 12 - 15, The `useAppBase`
helper is casting `window` to `any` to read `__HOMEBOX_BASE__`; replace that
with a proper `Window` interface augmentation so the global is typed safely.
While updating `useAppBase`, add a lightweight guard that returns the fallback
when `__HOMEBOX_BASE__` is missing or doesn’t start with `/`, since the value is
used downstream by `urls.ts` for URL construction.
frontend/nuxt.config.ts (1)

65-80: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: workbox version regex is limited to single-digit API versions.

/\/api\/v\d/ matches only v0-v9. Fine for the current v1 API, but if the API ever reaches double-digit versioning this pattern would silently stop matching new routes for caching/fallback purposes.

♻️ More future-proof pattern
-      navigateFallbackDenylist: [/\/api\/v\d/],
+      navigateFallbackDenylist: [/\/api\/v\d+/],
       cleanupOutdatedCaches: true,
       runtimeCaching: [
         {
-          urlPattern: /\/api\/v\d/,
+          urlPattern: /\/api\/v\d+/,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/nuxt.config.ts` around lines 65 - 80, The Workbox API route regex is
too narrow and only matches single-digit versions, so update the patterns in the
pwa.workbox config to support multi-digit API versions. Replace the current
regex used in both navigateFallbackDenylist and runtimeCaching.urlPattern with a
version that matches one or more digits, and keep the change localized to the
workbox configuration in nuxt.config.ts.
frontend/composables/use-server-events.ts (1)

74-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same inconsistency: raw base concatenation instead of the shared URL helper.

This mirrors the pattern in pages/index.vue — building ${base}api/v1/ws/events manually rather than through the route() helper already used in otel/index.ts. Consolidating on one helper reduces the risk of an inconsistent slash contract breaking WebSocket connectivity under a configured sub-path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/use-server-events.ts` around lines 74 - 78, The
WebSocket URL in useServerEvents is still built by manually concatenating the
base path with the events endpoint, which can drift from the shared URL
contract. Update the URL construction in useServerEvents to use the same route()
helper pattern already used elsewhere (for example in otel/index.ts) instead of
assembling "${base}api/v1/ws/events" directly, so sub-path handling stays
consistent under all configurations.
frontend/components/Collection/JoinModal.vue (1)

57-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate of the base-URL construction in invites.vue.

Same expression as pages/collection/index/invites.vue line 30 — consider consolidating into a shared composable to avoid maintaining the same base/origin logic in two places.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Collection/JoinModal.vue` at line 57, The base-URL
construction in JoinModal.vue is duplicated with invites.vue, so consolidate
this origin-building logic into a shared composable or helper and have JoinModal
use it instead of recreating the URL inline. Update the JoinModal setup code
around the domain calculation to call the shared utility, and keep the same
behavior for deriving the app origin from useAppBase() and window.location.
frontend/pages/collection/index/invites.vue (1)

30-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate base-URL construction shared with JoinModal.vue.

The exact same new URL(useAppBase(), \${window.location.protocol}//${window.location.host}`).toString()expression is also used incomponents/Collection/JoinModal.vue. Extracting this into useAppBase()(e.g., a companionuseAppOrigin()/useAppBaseUrl()) or the shared lib/api/base/urls.ts` helper would avoid the duplication and keep the base-URL contract in one place.

♻️ Suggested extraction
- const baseUrl = new URL(useAppBase(), `${window.location.protocol}//${window.location.host}`).toString();
+ const baseUrl = useAppOrigin(); // new shared composable wrapping the URL(...) logic
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/collection/index/invites.vue` at line 30, The base-URL
construction in this page duplicates the same logic used in JoinModal.vue, so
extract the shared URL-building behavior into a single helper such as
useAppOrigin/useAppBaseUrl or the existing lib/api/base/urls.ts utilities.
Update the local baseUrl computation in invetes.vue to call that shared helper
instead of rebuilding the URL inline, and then switch JoinModal.vue to the same
shared source so the contract stays in one place.
frontend/pages/index.vue (1)

216-219: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prefer the shared route() helper over manual concatenation; verify base-value trust boundary.

otel/index.ts builds backend URLs via route("/telemetry") from the shared ~~/lib/api/base/urls helper, but this redirect manually concatenates useAppBase() + "api/v1/users/login/oidc". Two different idioms for the same "base + path" operation increase the risk that one call site breaks if the base's leading/trailing slash contract ever changes (e.g., empty string vs "/" vs "/sub/").

Since this drives a full-page window.location.href navigation, please also confirm useAppBase() is sourced only from a trusted, server-injected value (e.g., window.__HOMEBOX_BASE__ set at boot) and never reflects user-controllable input — otherwise this pattern could become an open-redirect vector if the base is ever influenced by query params or similar.

♻️ Suggested consistency fix
+  import { route } from "~~/lib/api/base/urls";
+
   function loginWithOIDC() {
-    const base = useAppBase();
-    window.location.href = base + "api/v1/users/login/oidc";
+    window.location.href = route("/api/v1/users/login/oidc");
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/index.vue` around lines 216 - 219, The OIDC login redirect in
`loginWithOIDC()` should use the shared `route()` helper instead of manually
concatenating `useAppBase()` with the path, so it matches the existing
URL-building pattern used by `otel/index.ts` and stays safe if base slash
handling changes. Update the redirect to build the login URL through the shared
API/base URL helper, and verify `useAppBase()` only reads from a trusted
boot-time source such as `window.__HOMEBOX_BASE__`, not any user-controlled
input, since `window.location.href` performs a full navigation.
backend/app/api/routes.go (1)

300-320: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a warning when the <head> injection no-ops.

The baseURL patch below logs a warning if the exact Nuxt string isn't found, but the <base href>/config-script injection via strings.Replace(..., "<head>", ...) fails silently if the embedded index.html doesn't contain a literal lowercase <head> (e.g. minified output or <head lang="en">). Under a subpath that produces a broken SPA with no diagnostic. A quick guard mirrors the existing baseURL warning.

🪵 Proposed warning on missed injection
-		content := strings.Replace(string(raw), "<head>", "<head>"+injection, 1)
+		content := strings.Replace(string(raw), "<head>", "<head>"+injection, 1)
+		if !strings.Contains(content, injection) {
+			log.Warn().Msg("could not inject <base href> into index.html — SPA may break under subpath")
+		}

On the security side: nice work here — validating base via normalizePath and HTML-escaping it before injecting into both the attribute and the inline <script> is solid defense in depth against injection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/routes.go` around lines 300 - 320, The `<head>` injection in
the SPA fallback can fail silently because `strings.Replace(..., "<head>", ...)`
assumes an exact lowercase tag, so add a warning in the `indexHTML` assembly
path when the injection no-ops and `content` is unchanged. Use the existing
`baseURL` patch block and `log.Warn()` pattern in `routes.go` to detect when the
`public.Open("static/public/index.html")` content does not contain the expected
`<head>` marker, and emit a clear warning that the base href/script injection
was skipped so subpath routing may break.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/internal/sys/config/conf.go`:
- Around line 222-241: Harden normalizePath in conf.go by rejecting any path
that contains traversal segments like ".." before adding leading/trailing
slashes. Update the normalizePath function to keep the existing character
validation but add an explicit check for ".." (and any equivalent dot-dot path
segment) so inputs such as /app/../admin are rejected with a clear error,
preserving predictable base-path handling.

---

Nitpick comments:
In `@backend/app/api/routes.go`:
- Around line 300-320: The `<head>` injection in the SPA fallback can fail
silently because `strings.Replace(..., "<head>", ...)` assumes an exact
lowercase tag, so add a warning in the `indexHTML` assembly path when the
injection no-ops and `content` is unchanged. Use the existing `baseURL` patch
block and `log.Warn()` pattern in `routes.go` to detect when the
`public.Open("static/public/index.html")` content does not contain the expected
`<head>` marker, and emit a clear warning that the base href/script injection
was skipped so subpath routing may break.

In `@frontend/components/Collection/JoinModal.vue`:
- Line 57: The base-URL construction in JoinModal.vue is duplicated with
invites.vue, so consolidate this origin-building logic into a shared composable
or helper and have JoinModal use it instead of recreating the URL inline. Update
the JoinModal setup code around the domain calculation to call the shared
utility, and keep the same behavior for deriving the app origin from
useAppBase() and window.location.

In `@frontend/composables/use-app-base.ts`:
- Around line 12-15: The `useAppBase` helper is casting `window` to `any` to
read `__HOMEBOX_BASE__`; replace that with a proper `Window` interface
augmentation so the global is typed safely. While updating `useAppBase`, add a
lightweight guard that returns the fallback when `__HOMEBOX_BASE__` is missing
or doesn’t start with `/`, since the value is used downstream by `urls.ts` for
URL construction.

In `@frontend/composables/use-server-events.ts`:
- Around line 74-78: The WebSocket URL in useServerEvents is still built by
manually concatenating the base path with the events endpoint, which can drift
from the shared URL contract. Update the URL construction in useServerEvents to
use the same route() helper pattern already used elsewhere (for example in
otel/index.ts) instead of assembling "${base}api/v1/ws/events" directly, so
sub-path handling stays consistent under all configurations.

In `@frontend/nuxt.config.ts`:
- Around line 65-80: The Workbox API route regex is too narrow and only matches
single-digit versions, so update the patterns in the pwa.workbox config to
support multi-digit API versions. Replace the current regex used in both
navigateFallbackDenylist and runtimeCaching.urlPattern with a version that
matches one or more digits, and keep the change localized to the workbox
configuration in nuxt.config.ts.

In `@frontend/pages/collection/index/invites.vue`:
- Line 30: The base-URL construction in this page duplicates the same logic used
in JoinModal.vue, so extract the shared URL-building behavior into a single
helper such as useAppOrigin/useAppBaseUrl or the existing lib/api/base/urls.ts
utilities. Update the local baseUrl computation in invetes.vue to call that
shared helper instead of rebuilding the URL inline, and then switch
JoinModal.vue to the same shared source so the contract stays in one place.

In `@frontend/pages/index.vue`:
- Around line 216-219: The OIDC login redirect in `loginWithOIDC()` should use
the shared `route()` helper instead of manually concatenating `useAppBase()`
with the path, so it matches the existing URL-building pattern used by
`otel/index.ts` and stays safe if base slash handling changes. Update the
redirect to build the login URL through the shared API/base URL helper, and
verify `useAppBase()` only reads from a trusted boot-time source such as
`window.__HOMEBOX_BASE__`, not any user-controlled input, since
`window.location.href` performs a full navigation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 83b82c7c-4373-470e-8449-fc8ead2a7e09

📥 Commits

Reviewing files that changed from the base of the PR and between 91797ff and e7c4c33.

📒 Files selected for processing (19)
  • Dockerfile
  • backend/app/api/handlers/v1/controller.go
  • backend/app/api/handlers/v1/v1_ctrl_auth.go
  • backend/app/api/providers/oidc.go
  • backend/app/api/routes.go
  • backend/internal/sys/config/conf.go
  • backend/internal/sys/config/conf_normalize_test.go
  • docker-compose.yml
  • frontend/app.vue
  • frontend/components/Collection/JoinModal.vue
  • frontend/composables/use-app-base.ts
  • frontend/composables/use-server-events.ts
  • frontend/lib/api/base/index.test.ts
  • frontend/lib/api/base/urls.ts
  • frontend/lib/otel/index.ts
  • frontend/nuxt.config.ts
  • frontend/pages/collection/index/invites.vue
  • frontend/pages/index.vue
  • frontend/plugins/api-base.ts

Comment thread backend/internal/sys/config/conf.go
@coderabbitai coderabbitai Bot requested a review from katosdev July 6, 2026 11:14
@coderabbitai coderabbitai Bot added the go Pull requests that update Go code label Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⬆️ enhancement New feature or request go Pull requests that update Go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant